None
Заказчик: Инвесторы из фонда «Shut Up and Take My Money»
Цель исследования: подготовить исследование рынка Москвы, найти интересные особенности и презентовать полученные результаты, которые в будущем помогут в выборе подходящего инвесторам места.
База данных: датасет с заведениями общественного питания Москвы, составленный на основе данных сервисов Яндекс Карты и Яндекс Бизнес на лето 2022 года.
Описание данных:
name — название заведения;address — адрес заведения;category — категория заведения, например «кафе», «пиццерия» или «кофейня»;hours — информация о днях и часах работы;lat — широта географической точки, в которой находится заведение;lng — долгота географической точки, в которой находится заведение;rating — рейтинг заведения по оценкам пользователей в Яндекс Картах (высшая оценка — 5.0);price — категория цен в заведении, например «средние», «ниже среднего», «выше среднего» и так далее;avg_bill — строка, которая хранит среднюю стоимость заказа в виде диапазона;middle_avg_bill — число с оценкой среднего чека, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Средний счёт»:middle_coffee_cup — число с оценкой одной чашки капучино, которое указано только для значений из столбца avg_bill, начинающихся с подстроки «Цена одной чашки капучино»;chain — число, выраженное 0 или 1, которое показывает, является ли заведение сетевым;district — административный район, в котором находится заведение, например Центральный административный округ;seats — количество посадочных мест.# импортируем библиотеки
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
# Стандартные импорты plotly
import plotly.graph_objs as go
import plotly.express as px
from fuzzywuzzy import fuzz
import re
import json
from folium import Map, Choropleth, Marker
from folium.plugins import MarkerCluster
from folium.features import CustomIcon
# загружаем данные
df = pd.read_csv('C:/anaconda/Datasets/moscow_places.csv')
with open('C:/anaconda/Datasets/admin_level_geomap.geojson', encoding='utf-8') as f:
geo_json = json.load(f)
df.head()
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | WoWфли | кафе | Москва, улица Дыбенко, 7/1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.878494 | 37.478860 | 5.0 | NaN | NaN | NaN | NaN | 0 | NaN |
| 1 | Четыре комнаты | ресторан | Москва, улица Дыбенко, 36, корп. 1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.875801 | 37.484479 | 4.5 | выше среднего | Средний счёт:1500–1600 ₽ | 1550.0 | NaN | 0 | 4.0 |
| 2 | Хазри | кафе | Москва, Клязьминская улица, 15 | Северный административный округ | пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00... | 55.889146 | 37.525901 | 4.6 | средние | Средний счёт:от 1000 ₽ | 1000.0 | NaN | 0 | 45.0 |
| 3 | Dormouse Coffee Shop | кофейня | Москва, улица Маршала Федоренко, 12 | Северный административный округ | ежедневно, 09:00–22:00 | 55.881608 | 37.488860 | 5.0 | NaN | Цена чашки капучино:155–185 ₽ | NaN | 170.0 | 0 | NaN |
| 4 | Иль Марко | пиццерия | Москва, Правобережная улица, 1Б | Северный административный округ | ежедневно, 10:00–22:00 | 55.881166 | 37.449357 | 5.0 | средние | Средний счёт:400–600 ₽ | 500.0 | NaN | 1 | 148.0 |
df.hist(figsize=(15, 15))
plt.suptitle('Общее распределение данных в колонках')
plt.show()
def check_dataset(dataset):
# выводим общую информацию о датасете
print("Информация о датасете:")
print("--------------------")
dataset.info()
print("\n")
print("Описание данных:")
print("--------------------")
print(dataset.describe())
print("\n")
# проверяем наличие дубликатов в таблицах
print("Поиск явных дубликатов:")
print("--------------")
duplicates = dataset.duplicated()
if duplicates.any():
print(dataset[duplicates])
else:
print("Дубликаты не найдены")
print("\n")
# проверяем данные на наличие пропусков
print("Наличие пропусков в датасете (NaN):")
print("---------------------")
missing_values = dataset.isna().sum()
if missing_values.any():
print(missing_values)
else:
print("Пропуски отсутствуют (NaN)")
print("\n")
check_dataset(df)
Информация о датасете:
--------------------
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 8406 entries, 0 to 8405
Data columns (total 14 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 name 8406 non-null object
1 category 8406 non-null object
2 address 8406 non-null object
3 district 8406 non-null object
4 hours 7870 non-null object
5 lat 8406 non-null float64
6 lng 8406 non-null float64
7 rating 8406 non-null float64
8 price 3315 non-null object
9 avg_bill 3816 non-null object
10 middle_avg_bill 3149 non-null float64
11 middle_coffee_cup 535 non-null float64
12 chain 8406 non-null int64
13 seats 4795 non-null float64
dtypes: float64(6), int64(1), object(7)
memory usage: 919.5+ KB
Описание данных:
--------------------
lat lng rating middle_avg_bill \
count 8406.000000 8406.000000 8406.000000 3149.000000
mean 55.750109 37.608570 4.229895 958.053668
std 0.069658 0.098597 0.470348 1009.732845
min 55.573942 37.355651 1.000000 0.000000
25% 55.705155 37.538583 4.100000 375.000000
50% 55.753425 37.605246 4.300000 750.000000
75% 55.795041 37.664792 4.400000 1250.000000
max 55.928943 37.874466 5.000000 35000.000000
middle_coffee_cup chain seats
count 535.000000 8406.000000 4795.000000
mean 174.721495 0.381275 108.421689
std 88.951103 0.485729 122.833396
min 60.000000 0.000000 0.000000
25% 124.500000 0.000000 40.000000
50% 169.000000 0.000000 75.000000
75% 225.000000 1.000000 140.000000
max 1568.000000 1.000000 1288.000000
Поиск явных дубликатов:
--------------
Дубликаты не найдены
Наличие пропусков в датасете (NaN):
---------------------
name 0
category 0
address 0
district 0
hours 536
lat 0
lng 0
rating 0
price 5091
avg_bill 4590
middle_avg_bill 5257
middle_coffee_cup 7871
chain 0
seats 3611
dtype: int64
hours , price , avg_bill , middle_avg_bill , middle_coffee_cup , seats. Замена пропусков в каждом отдельном случае должно производиться по отдельной методике, чтобы сильно не искажать датасет синтетическими данными. Проверим датасет на наличие неявных дубликатов. Скорее всего они скрываются за ошибками в названии заведений.
# приведем строки к нижнему регистру
df['name'] = df['name'].str.lower()
df['address'] = df['address'].str.lower()
# напишем функцию, которая будет искать похожие названия из столбца dt['name']
def find_similar_names(names, similarity_threshold):
similar_names_dict = {}
num_names = len(names)
for i in range(num_names):
name1 = names[i]
for j in range(i+1, num_names):
name2 = names[j]
similarity_ratio = fuzz.ratio(name1.lower(), name2.lower())
if similarity_ratio >= similarity_threshold and name1 != name2:
if name1 not in similar_names_dict.values():
similar_names_dict[name1] = name2
break
return similar_names_dict
# names = df['name'].tolist()
# similarity_threshold = 90
# result = find_similar_names(names, similarity_threshold)
# print("Similar name dictionary:")
# for name, similar_names in result.items():
# print(f"'{name}': '{similar_names}',")
Код очень нагружает ядро. На выполнение ячейки приходится 5-6 минут. Пришлось закомментировать. Ниже я уже привожу названия к единому формату.
len(df['name'].unique())
5512
# заменим названия заведений из пар схожих на 1 корректное
df = df.replace({
'name': {
'чебуреки манты': 'чебуреки и манты',
'drive café': 'drive cafe',
'в парке вкуснее': 'в парке вкуснее!',
'домино\'с пицца': 'доминос пицца',
'суши пицца': 'суши & пицца',
'9 bar coffe': '9 bar coffee',
'cofe fest': 'cofefest',
'шашлык': 'шашлыки',
'хинкали gали!': 'хинкали-gали!',
'аль бухари': 'аль-бухари',
'халял': 'халяль',
'алло пицца': 'алло! пицца',
'meatлав': 'meat лав',
'чудо печка': 'чудо-печка',
'ho chu pho': 'ho cho pho',
'бистро 2': 'бистро 24',
'яндекс лавка': 'яндекс.лавка',
'донец кебаб': 'донер кебаб',
'суши стор': 'сушистор',
'мастер вкуса': 'мастервкус',
'глобус ресторан': 'глобус, ресторан',
'центр плов': 'центр плова',
'чайхона № 1': 'чайхона №1',
'кафе-пекарня': 'кафе пекарня',
'чайхана 24': 'чайхона 24/7',
'спорт кафе': 'спорт-кафе',
'чайхана халал': 'чайхона халаль',
'кружка паб': 'кружкапаб',
'вкинобар': 'кинобар',
'чайхана райан': 'чайхона райхан',
'кафе столовая': 'кафе-столовая',
'кофепорт': 'кофе-порт',
'донер тандыр': 'донер-тандыр',
'чай хана халал': 'чайхона халаль',
'донер хаус': 'донер-хаус',
'l cafe': 'la cafe',
'бистрот': 'бистро',
'чайхона манас': 'чайхона манас',
'буффет': 'буфет',
'перекресток': 'перекрёсток',
'донер24': 'донер 24',
'хинкали gали': 'хинкали-gали!',
'pizza amore': 'pizzamore',
'сити пицца': 'ситипицца',
'намангале': 'на мангале',
'стардогс': 'стардог',
'jeffrey’s coffeeshop': 'jeffrey\'s coffeeshop',
'дом 8а': 'дом 8',
'кофе&moloko': 'кофе & moloko',
'phobo': 'pho bo',
'shakeup': 'shake up',
'кафе кондитерская': 'кафе-кондитерская',
'кебабс': 'кебаб',
'хинкали - gали!': 'хинкали-gали!',
'донер и гриль': 'донер & гриль',
'corner cafe & kitchen': 'corner cafe&kitchen',
'хинкали-gaли': 'хинкали-gали!',
'it\'s сот - кофейня': 'it\'s сот-кофейня',
'гастрорюмочная шесть you шесть': 'гастрорюмочная шесть-you-шесть',
'чайхана 24': 'чайхона 24',
'кофе, пекарня': 'кафе, пекарня',
'подсолнухи': 'подсолнух',
'церковная трапезная': 'церковная трапеза',
'суши сет': 'сушисет',
'coffee in': 'coffee inn',
'чайхона ош сити': 'чайхона ош сити',
'чайхана24': 'чайхона 24/7',
'festa&тесто': 'festa & тесто',
'кебаб хауз': 'кебаб-хауз',
'сладкое': 'сладко',
'кебаб хаус': 'кебаб-хауз',
'шаурма 24': 'шаурма 24/7',
'багратиони': 'багратион',
'чайхона востока': 'чайхона востока 24',
'пицца-фабрика': 'пицца фабрика',
'шаурма донер': 'шаурма - донер',
'есть хинкали&пить вино': 'есть хинкали & пить вино',
'кочевник': 'кочевники',
'local': 'locals',
'онегин': 'онегинъ',
'caffetteria': 'caffeterria',
'фо бо и том ям': 'фо бо & том ям',
'лепим и варим пельменная на рынке № 2': 'лепим и варим пельменная на рынке',
'саббаба': 'сабаба',
'lav cafe': 'la cafe',
'хинкальная № 1': 'хинкальная № 8',
'восточный дворик': 'восточный двор',
'cafe 13': 'cafe13',
'buffet': 'bufet',
'чайхана-халва': 'чайхона халва',
'столовая-буфет': 'столовая - буфетъ',
'донер бургер': 'донер и бургер',
'трдельникъ': 'трдельник',
'чайхана-24': 'чайхана 24',
'да, еда': 'да еда',
'хинкали': 'хинкали+',
'чайхана бишкек сити': 'чайхона бишкек сити',
'хинкали хачапури': 'хинкали & хачапури',
'мисс лавнес': 'миславнес',
'main food': 'mainfood',
'кафе-гриль': 'кафе гриль',
'чайхана халва': 'чайхона халва',
'wild bean' : 'wild bean cafe',
'the wild bean cafe' : 'wild bean cafe',
'white fox' : 'white fox cafe'
}
})
len(df['name'].unique())
5413
Количество уникальных названий заведений сократилось на 200. Отличный результат. Осталось проверить по одинаковым написаниям и адресам наличие неявных дубликатов.
display(df.duplicated(subset=['name', 'address']).sum())
df.duplicated(subset=['name', 'lat', 'lng']).sum()
8
1
Целых 9 дубликатов! Удалим поскорее их датасета, чтобы не портили статистику.
df = df.drop_duplicates(subset=['name', 'address'])
df = df.drop_duplicates(subset=['name', 'lat', 'lng'])
# создадим словарь со всеми сетевыми заведениями Москвы
net_rest = df['name'].value_counts().to_dict()
# удалим из словаря заведения, которые не являются сетевыми (меньше 2 заведений)
net_rest = {key: value for key, value in net_rest.items() if value > 1}
net_list = list(net_rest.keys())
После того как мы отредактировали названия заведений, мы должны убедиться, что в столбце chain корректно отображается статус сетевого заведения.
df.loc[df['name'].isin(net_list) & ~(df['name'].isin(["кафе", "ресторан"])), 'chain'] = 1
df['chain'].value_counts()
0 4789 1 3609 Name: chain, dtype: int64
После корректировки маркера chain оказалось, что практически половина всех записей в нашем датасете - заведения как минимум с 2-мя точками по городу. Этот маркер дальше поможет нам заполнить некоторые пропуски в столбцах price, avg_bill, middle_avg_bill и middle_coffee_cup.
Наше предположение, что в сетевых заведениях должно быть одинаковое меню и, как следствие, одинаковый средний чек. Таким образом, если хотя бы у одного из заведений сети есть запись о среднем чеке, ее можно будет "подтянуть" и для всех остальных.
# заменим пропуски в столбце `price`, если выполняется 2 условия: кафе сетевое, у одного из кафе указана информация в столбце
df['price'] = (df.groupby('name')['price']
.transform(lambda x: x.fillna(x.mode().iloc[0])
if not x.mode().empty else x)
)
df['price'].isna().sum()
3616
С помощью такого фильтра, мы смогли заполнить 1.5 тысячи пропусков в столбце. Осталось ещё 3.6 тысяч.
Аналогичную процедуру выполним для столбца avg_bill.
df['avg_bill'] = (df.groupby('name')['avg_bill']
.transform(lambda x: x.fillna(x.mode().iloc[0])
if not x.mode().empty else x)
)
df['avg_bill'].isna().sum()
3289
Из изначальных 3816 пропусков осталось 3289. Количество удалось сократить на 527.
df['hours'] = (df.groupby('name')['hours']
.transform(lambda x: x.fillna(x.mode().iloc[0])
if not x.mode().empty else x)
)
df['hours'].isna().sum()
291
Аналогичным образом поступили с пропусками в столбце с часами работы. Скорее всего у сетевых заведений одинаковые часы работы. Количество пропусков уменьшилось вдвое.
# посмотрим, как отображаются категории у сетевых заведений
pd.set_option('display.max_rows', None)
df.query('chain == 1')[['name', 'category']].sort_values( by= 'name').head(10)
| name | category | |
|---|---|---|
| 7466 | 1-я креветочная | кафе |
| 430 | 10 идеальных пицц | ресторан |
| 5069 | 10 идеальных пицц | ресторан |
| 7590 | 10 идеальных пицц | ресторан |
| 2267 | 18 грамм | кофейня |
| 3282 | 18 грамм | кофейня |
| 4723 | 18 грамм | кофейня |
| 7009 | 4 сезона | кафе |
| 4397 | 7 сэндвичей | кофейня |
| 6020 | 7 сэндвичей | кофейня |
Можно заметить, что у сетевых заведений у разных ресторанов встречаются разные категории. Это необходимо подправить, чтобы одна сеть в дальнейшем анализе отображалась внутри одной категории.
def correct_category(df):
df['category'] = (df.groupby('name')['category']
.transform(lambda x: x.mode().iloc[0]))
return df
df = correct_category(df)
pd.set_option('display.max_rows', None)
df.query('chain == 1')[['name', 'category']].sort_values( by= 'name').head(10)
| name | category | |
|---|---|---|
| 7466 | 1-я креветочная | кафе |
| 430 | 10 идеальных пицц | ресторан |
| 5069 | 10 идеальных пицц | ресторан |
| 7590 | 10 идеальных пицц | ресторан |
| 2267 | 18 грамм | кофейня |
| 3282 | 18 грамм | кофейня |
| 4723 | 18 грамм | кофейня |
| 7009 | 4 сезона | кафе |
| 4397 | 7 сэндвичей | кофейня |
| 6020 | 7 сэндвичей | кофейня |
После унификации категорий для сетевых заведений стало заметно, что не все категории подтянулись корректно. Например, бургер-кинг отображается как ресторан, хотя по смыслу больше подходит к категории быстрое питание.
Можно дополнительно привести в порядок категории заведений следующим образом:
# определим список ключевых слов для поиска пиццерий и заменим категорию, если они встретятся
pizza_keywords = ['pizza', 'пицца', 'пиццерия']
df.loc[df['name'].str.contains('|'.join(pizza_keywords), case=False), 'category'] = 'пиццерия'
# изменим категорию для столовых
stol_keywords = ['столовая']
df.loc[df['name'].str.contains('|'.join(stol_keywords), case=False), 'category'] = 'столовая'
# изменим категорию для кофеен
coffee_keywords = ['coffee', 'кофе', 'кофейня']
df.loc[df['name'].str.contains('|'.join(coffee_keywords), case=False), 'category'] = 'кофейня'
# изменим категорию для булочных
baton_keywords = ['bakery', 'выпечка']
df.loc[df['name'].str.contains('|'.join(baton_keywords), case=False), 'category'] = 'булочная'
# точечно изменим категорию для бургер-кинга
df.loc[df['name'] == 'бургер кинг', 'category'] = 'быстрое питание'
Осталось разобраться с ценообразованием в столбцах avg_bill, middle_avg_bill, middle_coffee_cup.
avg_bill строка начинается с "Средний счёт: ...", значит столбец middle_avg_bill должен подтягивать указанный диапазон значений в виде медианного числа. avg_bill строка начинается с "Цена чашки капучино: ...", значит столбец middle_coffee_cup должен подтягивать указанный диапазон значений в виде медианного числа. Ранее мы уже заполнили пропуски у сетевых заведений в столбце avg_bill, там где смогли. Теперь попробуем подтянуть информацию в отсутствующие строки столбцов middle_avg_bill и middle_coffee_cup.
# напишем цикл, который пройдется по каждой строке и найдет медианное значение
# для столбцов 'middle_avg_bill' и 'middle_coffee_cup'
for i in range(len(df['avg_bill'])):
if i not in df.index:
continue
avg_bill_str = str(df['avg_bill'][i])
if 'Средний счёт' in avg_bill_str:
numbers = re.findall(r'\d+', avg_bill_str)
if len(numbers) >= 1:
median = float(numbers[0])
if len(numbers) == 2:
median = np.median([float(num) for num in numbers])
df.at[i, 'middle_avg_bill'] = median
elif 'Цена чашки' in avg_bill_str:
numbers = re.findall(r'\d+', avg_bill_str)
if len(numbers) >= 1:
median = float(numbers[0])
if len(numbers) == 2:
median = np.median([float(num) for num in numbers])
df.at[i, 'middle_coffee_cup'] = median
df[['avg_bill', 'middle_avg_bill', 'middle_coffee_cup']].isna().sum()
avg_bill 3289 middle_avg_bill 4243 middle_coffee_cup 7586 dtype: int64
Благодаря нашим манипуляциям, удалось сократить количество пропусков в столбце middle_avg_bill на 1000 строк, а в столбце middle_coffee_cup на 300. При этом, это всё ещё "натуральные" данные, а не синтетическая заливка (как заполнение нулем или медианой).
Так как пропусков в этих столбцах всё ещё остается много, заливать их синтетическими значениями не стоит - впоследствии это заметно исказит общую статистику.
# если в названии присутствует 24/7, то скорее всего заведение работает круглосуточно
df.loc[df['name'].str.contains('24/7', case=False), 'hours'] = 'ежедневно, круглосуточно'
# заменим написание адреса, когда указывается мкад
df['address'] = df['address'].str.replace('мкад,', 'мкад')
# создадим столбец, с названием улиц, указанных в столбце address
df['street'] = df['address'].str.split(pat=',', expand = True)[1]
# создадим столбец, с обозначением, что заведение работает ежедневно и круглосуточно (24/7)
df.loc[df['hours'] == 'ежедневно, круглосуточно', 'is_24'] = True
df.loc[df['hours'] != 'ежедневно, круглосуточно', 'is_24'] = False
Общий вывод по разделу
Нами была проведена обширная предобработка данных.
name и address приведено к нижнему регистру;chain, если название заведения встречалось несколько раз;price, avg_bill, middle_avg_bill, middle_coffee_cup за счет информации от других заведений сети;seats оставили без изменений, так как эта информация очень сильно варьируется от места к месту;street с названием улиц, указанных в столбце address;is_24 с обозначением, что заведение работает ежедневно и круглосуточно;cat_name = (df.pivot_table(index = 'category',
values ='address',
aggfunc='count')
.reset_index()
.sort_values(by = 'address', ascending= False)
)
total = cat_name['address'].sum()
cat_name['share'] = round(cat_name['address'] / total * 100, 2)
fig = px.bar(
cat_name,
x='category',
y='address',
color='category',
labels={'name': 'Название сети', 'address': 'Количество заведений по городу', 'category' : 'Категория'},
title='Категории заведений в г. Москва',
)
fig.update_layout(title='Категории заведений в г. Москва', title_x=0.5)
for i, row in cat_name.iterrows():
fig.add_annotation(x=row['category'], y=row['address'], text=str(row['share']), showarrow=False, font=dict(color='black'))
fig.show();
Промежуточный вывод
# высчитаем медиану по категории
median_by_category = df.groupby('category')['seats'].median().sort_values(ascending=False)
# сортируем датафрейм по медианному значению
df_sorted = df[df['category'].isin(median_by_category.index)].copy()
df_sorted['category'] = pd.Categorical(df_sorted['category'], categories=median_by_category.index, ordered=True)
df_sorted.sort_values('category', inplace=True)
# создаем boxplot
fig = px.box(df_sorted,
x='category',
y='seats',
color='category')
fig.update_layout(title='Распределение мест по категориям заведений', title_x=0.5)
fig.update_xaxes(title='Категория заведений')
fig.update_yaxes(title='Количество мест')
fig.show()
Промежуточный вывод
net_name = df.pivot_table(index = 'chain', values ='address', aggfunc='count').reset_index()
net_name['chain'] = net_name['chain'].map({0: 'несетевое', 1: 'сетевое'})
fig = go.Figure(data=[go.Pie(labels=net_name['chain'], values=net_name['address'])])
fig.update_traces(hoverinfo='label+value+percent',
textinfo='value+percent',
textfont_size=11)
fig.update_layout(title='Соотношение сетевых и несетевых заведений в Москве', title_x=0.5)
fig.show();
Промежуточный вывод
plt.figure(figsize=(8,8))
sns.histplot(data=df_sorted,
y='category',
hue='chain',
multiple='fill',
shrink=0.7,
edgecolor=None,
palette=('#636EFA', '#EF553B')
)
plt.xlabel('Доля')
plt.ylabel('Категория заведений')
plt.legend(labels=['сетевые', 'несетевые'])
plt.gca().set_title('Доля сетевых и несетевых заведений для каждой категории заведений')
sns.despine()
plt.show();
Промежуточный вывод
# посчитаем количество заведений внутри первых 15 сетей в Москве
top_15 = df.query('chain ==1').groupby('name').size().reset_index(name='count')
top_15 = top_15.merge(df[['name', 'category']], on='name', how='left')
top_15 = top_15.drop_duplicates(subset=['name']).sort_values(by='count', ascending = False).head(15)
top_15
| name | count | category | |
|---|---|---|---|
| 3361 | шоколадница | 120 | кофейня |
| 1583 | доминос пицца | 78 | пиццерия |
| 1498 | додо пицца | 74 | пиццерия |
| 604 | one price coffee | 72 | кофейня |
| 3529 | яндекс.лавка | 72 | ресторан |
| 234 | cofix | 65 | кофейня |
| 787 | prime | 50 | ресторан |
| 3008 | хинкальная | 44 | кафе |
| 3266 | шаурма | 43 | быстрое питание |
| 1933 | кофе-порт | 43 | кофейня |
| 2065 | кулинарная лавка братьев караваевых | 39 | кафе |
| 2847 | теремок | 38 | ресторан |
| 3120 | чайхана | 37 | кафе |
| 147 | cofefest | 33 | кофейня |
| 1226 | буханка | 32 | булочная |
df_split = top_15.assign(category=top_15['category'].str.split(',')).explode('category')
fig = px.bar(
df_split,
x='name',
y='count',
color='category',
labels={'name': 'Название сети', 'count': 'Количество заведений по городу', 'category' : 'Категория'},
title='Топ-15 популярных сетей в Москве',
)
fig.update_layout(title_x=0.5, showlegend=True)
fig.update_layout(barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()
Промежуточный вывод
districts = (df.groupby(['district','category'], as_index=False)['name']
.count()
.sort_values(by='name', ascending=False)
)
sum_by_district = (districts
.groupby('district')['name']
.sum()
.reset_index()
.sort_values(by ='name', ascending = False)
)
fig = px.bar(districts.sort_values(by='name', ascending=True), x='name', y='district', color='category')
for i, row in sum_by_district.iterrows():
fig.add_annotation(x=row['name'], y=row['district'], text=str(row['name']), showarrow=False, font=dict(color='black'))
fig.update_layout(title='Разбивка заведений по районам Москвы',
title_x=0.5,
xaxis_title='Количество заведений',
yaxis_title='Район')
fig.update_traces(textposition='outside')
fig.update_layout(barmode='stack', yaxis={'categoryorder':'total ascending'})
fig.show()
Промежуточный вывод
mean_by_category = (df.pivot_table(index = 'category', values = 'rating', aggfunc='mean')
.reset_index()
.sort_values(by='rating', ascending = False)
)
fig = px.bar(mean_by_category,
x='category',
y='rating'
)
fig.update_layout(title='Распределение средних рейтингов по категориям заведений',
title_x=0.5,
xaxis_title='Категория заведений',
yaxis_title='Средняя оценка посетителей')
fig.show()
Промежуточный вывод
# создаем датасет со средними оценками заведений в зависимости от АО г. Москвы
mean_by_district = (df.pivot_table(index = 'district', values = 'rating', aggfunc='mean')
.reset_index()
.sort_values(by='rating', ascending = False)
)
# загружаем JSON-файл с границами округов Москвы
state_geo = 'C:/anaconda/Datasets/admin_level_geomap.geojson'
# moscow_lat - широта центра Москвы, moscow_lng - долгота центра Москвы
moscow_lat, moscow_lng = 55.751244, 37.618423
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём хороплет с помощью конструктора Choropleth и добавляем его на карту
Choropleth(
geo_data=geo_json,
data=mean_by_district,
columns=['district', 'rating'],
key_on='feature.name',
fill_color='PuRd',
fill_opacity=0.75,
legend_name='Средний рейтинг заведений по районам',
).add_to(m)
# выводим карту
m
Промежуточный вывод
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём пустой кластер, добавляем его на карту
marker_cluster = MarkerCluster().add_to(m)
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
Marker(
[row['lat'], row['lng']],
popup=f"{row['name']} {row['rating']}",
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма
df.apply(create_clusters, axis=1)
# выводим карту
m
# найдем названия 15 самых популярных улиц по количеству заведений
top_15_street_name = (df.pivot_table(index='street', values = 'name', aggfunc = 'count')
.sort_values(by='name', ascending = False)
.reset_index()
.head(15)
)
top_15_list = top_15_street_name['street'].to_list()
top_15_list
[' проспект мира', ' профсоюзная улица', ' проспект вернадского', ' ленинский проспект', ' ленинградский проспект', ' дмитровское шоссе', ' каширское шоссе', ' варшавское шоссе', ' ленинградское шоссе', ' люблинская улица', ' улица вавилова', ' кутузовский проспект', ' улица миклухо-маклая', ' пятницкая улица', ' алтуфьевское шоссе']
# сделаем группировку по названию улицы и по кол-ву заведений представленных в каждой категории
top_15_street = (df.query('street in @top_15_list')
.pivot_table(index = ['street', 'category'], values = 'name', aggfunc= 'count')
.reset_index()
)
top_15_street.head(10)
| street | category | name | |
|---|---|---|---|
| 0 | алтуфьевское шоссе | бар,паб | 4 |
| 1 | алтуфьевское шоссе | булочная | 1 |
| 2 | алтуфьевское шоссе | быстрое питание | 7 |
| 3 | алтуфьевское шоссе | кафе | 16 |
| 4 | алтуфьевское шоссе | кофейня | 8 |
| 5 | алтуфьевское шоссе | пиццерия | 3 |
| 6 | алтуфьевское шоссе | ресторан | 8 |
| 7 | варшавское шоссе | бар,паб | 5 |
| 8 | варшавское шоссе | быстрое питание | 9 |
| 9 | варшавское шоссе | кафе | 18 |
# строим визуализацию
fig = px.bar(
top_15_street,
x='street',
y='name',
color='category',
labels={'name': 'Количество заведений', 'street': ' ', 'category' : 'Категория'},
title='Топ-15 популярных улиц в Москве'
)
fig.update_layout(title_x=0.5, barmode='stack', xaxis={'categoryorder':'total descending'})
# добавляем общий счетчик кол-ва заведений на улице
for i, row in top_15_street_name.iterrows():
fig.add_annotation(x=row['street'], y=row['name'], text=str(row['name']), showarrow=False, font=dict(color='black'))
fig.show()
Промежуточный вывод
# найдем названия улиц, где всего 1 заведение
bottom_street_name = (df.pivot_table(index='street', values = 'name', aggfunc = 'count')
.sort_values(by='name', ascending = True)
.reset_index()
)
bottom_street_name = bottom_street_name.query('name ==1')
bottom_street_name = bottom_street_name['street'].to_list()
len(bottom_street_name)
468
# сделаем группировку по названию улицы и по кол-ву заведений представленных в каждой категории
bottom_street = (df.query('street in @bottom_street_name')
.pivot_table(index = ['street','category', 'rating'], values = 'name', aggfunc= 'count')
.reset_index()
)
bottom_street = (bottom_street.pivot_table(index ='category', values ='street', aggfunc ='count')
.reset_index()
.sort_values(by='street', ascending = False)
)
bottom_street
| category | street | |
|---|---|---|
| 3 | кафе | 167 |
| 4 | кофейня | 93 |
| 6 | ресторан | 85 |
| 0 | бар,паб | 40 |
| 7 | столовая | 32 |
| 2 | быстрое питание | 24 |
| 5 | пиццерия | 17 |
| 1 | булочная | 10 |
# Построим визуализацию, где находятся те улицы, на которых представлено всего 1 заведение
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём пустой кластер, добавляем его на карту
marker_cluster = MarkerCluster().add_to(m)
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
Marker(
[row['lat'], row['lng']],
popup=f"{row['name']} {row['rating']}",
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма
df.query('street in @bottom_street_name').apply(create_clusters, axis=1)
# выводим карту
m
Промежуточный вывод
# создаем датасет со средним чеком заведений в зависимости от АО г. Москвы
median_price = (df.pivot_table(index = 'district', values = 'middle_avg_bill', aggfunc='median')
.reset_index()
.sort_values(by='middle_avg_bill', ascending = False)
)
# moscow_lat - широта центра Москвы, moscow_lng - долгота центра Москвы
moscow_lat, moscow_lng = 55.751244, 37.618423
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём хороплет с помощью конструктора Choropleth и добавляем его на карту
Choropleth(
geo_data=geo_json,
data=median_price,
columns=['district', 'middle_avg_bill'],
key_on='feature.name',
fill_color='PuRd',
fill_opacity=0.75,
legend_name='Медианный чек заведений по районам',
).add_to(m)
# выводим карту
m
Промежуточный вывод
Для начала найдем общее количество кофеен, представленных в датасете.
df_c = df.query('category == "кофейня"')
print("Общее количество кофеен в Москве: ",len(df_c))
Общее количество кофеен в Москве: 1457
# сделаем группировку по АО и по кол-ву кофеен
coffee_district = (df_c
.pivot_table(index = ['district', 'chain'], values = 'name', aggfunc= 'count')
.reset_index()
.sort_values(by = 'district')
)
coffee_district_total = (df_c
.pivot_table(index = 'district', values = 'name', aggfunc= 'count')
.reset_index()
.sort_values(by = 'name', ascending= False)
)
coffee_district_total
| district | name | |
|---|---|---|
| 5 | Центральный административный округ | 435 |
| 2 | Северный административный округ | 194 |
| 1 | Западный административный округ | 161 |
| 3 | Северо-Восточный административный округ | 159 |
| 8 | Южный административный округ | 138 |
| 0 | Восточный административный округ | 108 |
| 7 | Юго-Западный административный округ | 101 |
| 6 | Юго-Восточный административный округ | 98 |
| 4 | Северо-Западный административный округ | 63 |
# Построим визуализацию, где именно располагаются кофейни
# создаём карту Москвы
m = Map(location=[moscow_lat, moscow_lng], zoom_start=10)
# создаём пустой кластер, добавляем его на карту
marker_cluster = MarkerCluster().add_to(m)
# пишем функцию, которая принимает строку датафрейма,
# создаёт маркер в текущей точке и добавляет его в кластер marker_cluster
def create_clusters(row):
icon_url = 'https://img.icons8.com/?size=512&id=ily57WQzKNzM&format=png'
icon = CustomIcon(icon_url, icon_size=(30, 30))
Marker(
[row['lat'], row['lng']],
popup=f"{row['name']} {row['rating']}",
icon=icon
).add_to(marker_cluster)
# применяем функцию create_clusters() к каждой строке датафрейма
df_c.apply(create_clusters, axis=1)
# выводим карту
m
# строим визуализацию
fig = px.histogram(
coffee_district,
x='name',
y='district',
color='chain',
labels={'name': 'Количество кофеен', 'district': ' ', },
title='Распределение кофеен в Москве'
)
fig.update_layout(title_x=0.5,
barmode='stack',
yaxis={'categoryorder':'total ascending'},
legend_title_text=' '
)
# добавляем общий счетчик кол-ва кофеен
for i, row in coffee_district_total.iterrows():
fig.add_annotation(y=row['district'],
x=row['name'],
text=str(row['name']),
showarrow=False,
font=dict(color='black')
)
fig['data'][1]['name'] = 'несетевое'
fig['data'][0]['name'] = 'сетевое'
fig.show()
Промежуточный вывод
# сделаем группировку по названию улицы и по кол-ву кофеен на этой улице
top_coffee_streets = (df_c
.pivot_table(index = ['street', 'district'],
values = 'name',
aggfunc= 'count')
.reset_index()
.sort_values(by = 'name', ascending = False)
)
print("Среднее количество кофеен на 1 улице: ",round(top_coffee_streets['name'].mean(), 1))
top_coffee_streets= top_coffee_streets.head(20)
Среднее количество кофеен на 1 улице: 2.2
# строим визуализацию
fig = px.bar(
top_coffee_streets,
x='street',
y='name',
color='district',
labels={'name': 'Количество кофеен', 'street': ' ', 'district' : 'Район города'},
title='Топ-20 популярных улиц по количеству кофеен'
)
fig.update_layout(title_x=0.5, barmode='stack', xaxis={'categoryorder':'total descending'})
fig.show()
print("Всего улиц в датасете: ", len(df['street'].unique()))
print("Количество улиц, на которых расположена как минимум 1 кофейня: ",
len(df_c['street'].unique()))
Всего улиц в датасете: 1468 Количество улиц, на которых расположена как минимум 1 кофейня: 643
Промежуточный вывод
# найдем количество кофеен, которые работают круглосуточно
len(df_c[df_c['is_24']])
79
# найдем самые частые режимы работы для заведений-кофеен
(df_c['hours']
.value_counts()
.head(20)
)
ежедневно, 10:00–22:00 142 ежедневно, круглосуточно 79 ежедневно, 09:00–22:00 55 ежедневно, 08:00–22:00 53 пн-пт 08:00–19:00 47 ежедневно, 09:00–21:00 46 пн-пт 08:00–21:00; сб,вс 09:00–21:00 46 пн-пт 08:00–22:00; сб,вс 09:00–22:00 40 ежедневно, 08:00–21:00 37 пн-пт 08:00–18:30 33 ежедневно, 08:00–23:00 31 ежедневно, 10:00–21:00 27 пн-пт 08:00–22:00; сб,вс 10:00–22:00 26 ежедневно, 08:00–20:00 24 ежедневно, 10:00–23:00 21 ежедневно, 07:00–22:00 21 пн-пт 08:00–18:00 18 пн-пт 08:00–20:00 18 пн-чт 10:00–22:00; пт,сб 10:00–23:00; вс 10:00–22:00 16 пн-пт 08:00–20:00; сб,вс 09:00–20:00 15 Name: hours, dtype: int64
Промежуточный вывод
# сгруппируем датасет по среднему рейтингу и району города
df_c_rating = df_c.pivot_table(index = ['rating', 'district'], values = 'name', aggfunc = 'count').reset_index()
fig = px.bar(
df_c_rating,
x='rating',
y='name',
color='district',
labels={'name': 'Количество оценок', 'rating': 'оценка', 'district' : 'Район города'},
title='Распределение оценок в зависимости от района города'
)
fig.update_layout(title_x=0.5)
fig.show()
# высчитаем медиану по категории
median_by_district = df_c.groupby('district')['rating'].median().sort_values(ascending=False)
# сортируем датафрейм по медианному значению
df_c_sorted = df_c[df_c['district'].isin(median_by_district.index)].copy()
df_c_sorted['district'] = pd.Categorical(df_c_sorted['district'], categories=median_by_district.index, ordered=True)
df_c_sorted.sort_values('district', inplace=True)
# создаем boxplot
fig = px.box(df_c_sorted,
x='district',
y='rating',
color='district'
)
fig.update_layout(title='Распределение оценок по районам города', title_x=0.5)
fig.update_yaxes(title='Оценка')
fig.update_xaxes(title='', showticklabels=False)
fig.show()
df_c['middle_coffee_cup'].describe()
count 794.000000 mean 163.786524 std 86.487963 min 60.000000 25% 95.000000 50% 155.000000 75% 225.000000 max 1568.500000 Name: middle_coffee_cup, dtype: float64
Если не углубляться в характеристики, то средняя цена в чашки капучино будет в Москве стоит 163 рублей, а медианная - 155.
При этом легко можно заметить, что средняя цена отличается от медианной за счет высокого максимального показателя - аж 1568 рублей за чашку.
fig = px.histogram(df_c,
x="middle_coffee_cup",
title='Гистограмма распределения среднего чека за чашку',
nbins=500
)
fig.update_layout(title_x=0.5)
fig.update_yaxes(title='Количество заведений')
fig.update_xaxes(title='Средняя цена за чашку капучино')
fig.show()
pivoted_df = (df_c.pivot_table(index='district', columns='chain', values='middle_coffee_cup')
.reset_index()
)
pivoted_df = pivoted_df.rename(columns={0: 'Несетевые', 1: 'Сетевые'})
pivoted_df = pivoted_df.sort_values(by = 'Несетевые', ascending = False)
fig = px.bar(
pivoted_df,
x=['Несетевые', 'Сетевые'],
y='district',
barmode='group',
labels={'value': 'Средняя цена за чашку капучино', 'district': 'Район города'},
title='Средняя цена чашки кофе в зависимости от района города'
)
fig.update_layout(title_x=0.5)
fig.show()
Вывод по разделу
Рекомендации к открытию кофейни